Анализ результатов A/B-теста¶

Цель¶

  • Оценить эффективность внедрения новой рекомендательной системы - проверить гипотезу об увеличении коневерсии.

Задачи¶

  • Оценить корректность проведения теста - проверить коректность выбора аудитории и ее распределения между группами.
  • Проанализировать результаты теста - протестировать статистическую гипотезу о равенстве долей.

Чтобы оценить корректность проведения теста, проверим:

  • информация о скольких А/В тестах содержится в данных,
  • соответствие географии участников теста,
  • пересечение тестовой аудитории с конкурирующим тестом (если их несколько),
  • все ли пользователи, зарегистрированные в тесте, совершали за период проведения теста события,
  • совпадение теста и маркетинговых событий,
  • соответствие временных границ теста.

Это поможет оценить корректность работы системы распределения пользователей по группам и окончательно определиться с аудиторией теста.

Техническое задание А/В-теста¶

  • Название теста: recommender_system_test;
  • группы: А — контрольная, B — новая платёжная воронка;
  • дата запуска: 2020-12-07;
  • дата остановки набора новых пользователей: 2020-12-21;
  • дата остановки: 2021-01-04;
  • аудитория: 15% новых пользователей из региона EU;
  • назначение теста: тестирование изменений, связанных с внедрением улучшенной рекомендательной системы;
  • ожидаемое количество участников теста: 6000;
  • ожидаемый эффект: за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%:
    • конверсии в просмотр карточек товаров — событие product_page,
    • просмотры корзины — product_cart,
    • покупки — purchase.

Загрузка данных, предобработка¶

Считывание данных¶

Подключим необходимые для работы библиотеки.

In [1]:
import pandas as pd
import numpy as np
import scipy.stats as st
import math as mth
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
from plotly import graph_objects as go

Настроим отображение данных.

In [2]:
pd.set_option('precision', 2)

Считаем данные в датафреймы.

In [3]:
try:
    events = pd.read_csv('datasets/final_ab_events.csv')
    marketing_events = pd.read_csv('datasets/ab_project_marketing_events.csv')
    new_users = pd.read_csv('datasets/final_ab_new_users.csv')
    tests = pd.read_csv('datasets/final_ab_participants.csv')
except:
    events = pd.read_csv('/datasets/final_ab_events.csv')
    marketing_events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
    new_users = pd.read_csv('/datasets/final_ab_new_users.csv')
    tests = pd.read_csv('/datasets/final_ab_participants.csv')

Обзор данных¶

Ознакомимся с данными. Изучим общую информацию о таблицах, уникальные значения атрибутов.

Данные о маркетинговых событиях¶

Изучим календарь маркетинговых акций.

In [4]:
marketing_events
Out[4]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
5 Black Friday Ads Campaign EU, CIS, APAC, N.America 2020-11-26 2020-12-01
6 Chinese New Year Promo APAC 2020-01-25 2020-02-07
7 Labor day (May 1st) Ads Campaign EU, CIS, APAC 2020-05-01 2020-05-03
8 International Women's Day Promo EU, CIS, APAC 2020-03-08 2020-03-10
9 Victory Day CIS (May 9th) Event CIS 2020-05-09 2020-05-11
10 CIS New Year Gift Lottery CIS 2020-12-30 2021-01-07
11 Dragon Boat Festival Giveaway APAC 2020-06-25 2020-07-01
12 Single's Day Gift Promo APAC 2020-11-11 2020-11-12
13 Chinese Moon Festival APAC 2020-10-01 2020-10-07
In [5]:
display(marketing_events['start_dt'].min())
display(marketing_events['finish_dt'].max())
'2020-01-25'
'2021-01-07'

Имеем список маркетинговый акций, проводившихся в период с 2020-01-25 по 2021-01-07, следовательно, имеются маркетинговые активности в период проведения A/B-теста. Изучим эту информацию в дальнейшем. Пропуски в данных отсутствуют.

Данные о новых пользователях¶

Изучим данные о новых пользователях, привлеченных в период проведения A/B-теста.

In [6]:
display(new_users.info())
print('Dataframe keeps {} records by {} attributes\n'. format(new_users.shape[0], new_users.shape[1]))
print('Number of unique items in attributes:')
for col in new_users.columns:
    print('\t- number of unique "{}": {}'.format(col, new_users[col].nunique()))
    if new_users[col].nunique() < 10:
        print('\t\tthey are:')
        print('\t\t{}'.format(new_users[col].unique()))
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   user_id     61733 non-null  object
 1   first_date  61733 non-null  object
 2   region      61733 non-null  object
 3   device      61733 non-null  object
dtypes: object(4)
memory usage: 1.9+ MB
None
Dataframe keeps 61733 records by 4 attributes

Number of unique items in attributes:
	- number of unique "user_id": 61733
	- number of unique "first_date": 17
	- number of unique "region": 4
		they are:
		['EU' 'N.America' 'APAC' 'CIS']
	- number of unique "device": 4
		they are:
		['PC' 'Android' 'iPhone' 'Mac']
In [7]:
display(new_users['first_date'].min())
display(new_users['first_date'].max())
'2020-12-07'
'2020-12-23'

Имеем список из 61733 записей о пользователях, зарегистрировавшихся в период с 2020-12-07 по 2020-12-23.

Для каждого пользователя указаны id, дата регистрации, регион и устройство.

Пропуски в данных отсутствуют.

Данные о действиях пользователей¶

Изучим данные о действиях новых пользователях.

In [8]:
display(events.info())
print('Dataframe keeps {} records by {} attributes\n'. format(events.shape[0], events.shape[1]))
print('Number of unique items in attributes:')
for col in events.columns:
    print('\t- number of unique "{}": {}'.format(col, events[col].nunique()))
    if events[col].nunique() < 10:
        print('\t\tthey are:')
        print('\t\t{}'.format(events[col].unique()))
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 4 columns):
 #   Column      Non-Null Count   Dtype  
---  ------      --------------   -----  
 0   user_id     440317 non-null  object 
 1   event_dt    440317 non-null  object 
 2   event_name  440317 non-null  object 
 3   details     62740 non-null   float64
dtypes: float64(1), object(3)
memory usage: 13.4+ MB
None
Dataframe keeps 440317 records by 4 attributes

Number of unique items in attributes:
	- number of unique "user_id": 58703
	- number of unique "event_dt": 267268
	- number of unique "event_name": 4
		they are:
		['purchase' 'product_cart' 'product_page' 'login']
	- number of unique "details": 4
		they are:
		[ 99.99   9.99   4.99 499.99    nan]
In [9]:
display(events['event_dt'].min())
display(events['event_dt'].max())
'2020-12-07 00:00:33'
'2020-12-30 23:36:33'

Имеем список событий четырех типов: 'purchase' (покупка), 'product_cart' (страница корзины), 'product_page' (страница товара), 'login'(вход).

Всего - 440317 записей для 58703 пользователей в период с 2020-12-07 по 2020-12-30, что не соответствует ТЗ (тест должен был проводиться до 2021-01-04). Подробнее изучим эти данные позднее.

'details' - дополнительные данные о событии 'purchase' - стоимость покупки в долларах. В данном поле имеются пропуски. Изучим их позднее.

Данные об участниках тестов¶

Изучим данные о проводимых A/B-тестах и их участниках.

In [10]:
tests.info()
print('\nDataframe keeps {} records by {} attributes\n'. format(tests.shape[0], tests.shape[1]))
print('Number of unique items in attributes:')
for col in tests.columns:
    print('\t- number of unique "{}": {}'.format(col, tests[col].nunique()))
    if tests[col].nunique() < 10:
        print('\t\tthey are:')
        print('\t\t{}'.format(tests[col].unique()))
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  18268 non-null  object
 1   group    18268 non-null  object
 2   ab_test  18268 non-null  object
dtypes: object(3)
memory usage: 428.3+ KB

Dataframe keeps 18268 records by 3 attributes

Number of unique items in attributes:
	- number of unique "user_id": 16666
	- number of unique "group": 2
		they are:
		['A' 'B']
	- number of unique "ab_test": 2
		they are:
		['recommender_system_test' 'interface_eu_test']

Имеем 18268 записей о пользователях, принявших участие в двух A/B-тестах: 'recommender_system_test' и 'interface_eu_test'. Среди них - 16666 уникальных записей, следовательно имеются пересечения между тестами или группами тестов. Изучим это в дальнейшем. Пропуски в данных отсутствуют.

Выводы по обзору данных¶

Имеем все необходимые для анализа данные. В данных имеется ряд артефактов, а также на первый взгляд имеются несоответсвия требованиям ТЗ. Оценим их позднее после подготовки данных для анализа.

Предобработка данных¶

Преобразование тиипов данных¶

Преобразуем данные о дате/времени в соответствующий тип. Добавим в логи столбец с датой события.

In [11]:
events['event_dt'] = pd.to_datetime(events['event_dt'])
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'])
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'])
new_users['first_date'] = pd.to_datetime(new_users['first_date'])
events['date'] = events['event_dt'].dt.normalize()

Преобразуем некоторые строковые данные в категориальный тип для уменьшения объемов памяти, занимаемой датафреймами.

In [12]:
events['event_name'] = events['event_name'].astype('category')
new_users['region'] = new_users['region'].astype('category')
new_users['device'] = new_users['device'].astype('category')
tests['group'] = tests['group'].astype('category')
tests['ab_test'] = tests['ab_test'].astype('category')

Преобразуем тип данных стоимостей покупок во float32, так как такой точности будет достаточно, и это уменьшит объем используемой памяти. Настроим отображение данных.

In [13]:
events['details'] = events['details'].astype('float32')

Проверим корректность преобразований.

In [14]:
display(events['details'].unique())

for df in [events, marketing_events, new_users, tests]:
    display(df.info())
    display(df.head())
    print()
array([ 99.99,   9.99,   4.99, 499.99,    nan], dtype=float32)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 5 columns):
 #   Column      Non-Null Count   Dtype         
---  ------      --------------   -----         
 0   user_id     440317 non-null  object        
 1   event_dt    440317 non-null  datetime64[ns]
 2   event_name  440317 non-null  category      
 3   details     62740 non-null   float32       
 4   date        440317 non-null  datetime64[ns]
dtypes: category(1), datetime64[ns](2), float32(1), object(1)
memory usage: 12.2+ MB
None
user_id event_dt event_name details date
0 E1BDDCE0DAFA2679 2020-12-07 20:22:03 purchase 99.99 2020-12-07
1 7B6452F081F49504 2020-12-07 09:22:53 purchase 9.99 2020-12-07
2 9CD9F34546DF254C 2020-12-07 12:59:29 purchase 4.99 2020-12-07
3 96F27A054B191457 2020-12-07 04:02:40 purchase 4.99 2020-12-07
4 1FD7660FDF94CA1F 2020-12-07 10:15:09 purchase 4.99 2020-12-07
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 14 entries, 0 to 13
Data columns (total 4 columns):
 #   Column     Non-Null Count  Dtype         
---  ------     --------------  -----         
 0   name       14 non-null     object        
 1   regions    14 non-null     object        
 2   start_dt   14 non-null     datetime64[ns]
 3   finish_dt  14 non-null     datetime64[ns]
dtypes: datetime64[ns](2), object(2)
memory usage: 576.0+ bytes
None
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   user_id     61733 non-null  object        
 1   first_date  61733 non-null  datetime64[ns]
 2   region      61733 non-null  category      
 3   device      61733 non-null  category      
dtypes: category(2), datetime64[ns](1), object(1)
memory usage: 1.1+ MB
None
user_id first_date region device
0 D72A72121175D8BE 2020-12-07 EU PC
1 F1C668619DFE6E65 2020-12-07 N.America Android
2 2E1BF1D4C37EA01F 2020-12-07 EU PC
3 50734A22C0C63768 2020-12-07 EU iPhone
4 E1BDDCE0DAFA2679 2020-12-07 N.America iPhone
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype   
---  ------   --------------  -----   
 0   user_id  18268 non-null  object  
 1   group    18268 non-null  category
 2   ab_test  18268 non-null  category
dtypes: category(2), object(1)
memory usage: 178.8+ KB
None
user_id group ab_test
0 D1ABA3E2887B6A73 A recommender_system_test
1 A7A3664BD6242119 A recommender_system_test
2 DABC14FDDFADD29E A recommender_system_test
3 04988C5DF189632E A recommender_system_test
4 482F14783456D21B B recommender_system_test

Данные преобразованы корректно.

Обработка пропусков¶

Оценим количество пропусков в данных.

In [15]:
for df in [events, marketing_events, new_users, tests]:
    for col in df.columns:
        print('Пропуски в {}: {}'.format(col, df[col].isnull().sum()))
Пропуски в user_id: 0
Пропуски в event_dt: 0
Пропуски в event_name: 0
Пропуски в details: 377577
Пропуски в date: 0
Пропуски в name: 0
Пропуски в regions: 0
Пропуски в start_dt: 0
Пропуски в finish_dt: 0
Пропуски в user_id: 0
Пропуски в first_date: 0
Пропуски в region: 0
Пропуски в device: 0
Пропуски в user_id: 0
Пропуски в group: 0
Пропуски в ab_test: 0

Видим, что пропуски имеются только в поле details датафрейма events. Изучим их подробнее.

In [16]:
events[events['details'].isna()]['event_name'].unique()
Out[16]:
['product_cart', 'product_page', 'login']
Categories (3, object): ['product_cart', 'product_page', 'login']
In [17]:
events[events['details'].notna()]['event_name'].unique()
Out[17]:
['purchase']
Categories (1, object): ['purchase']

Видим, что пропуски в указанном поле соответствуют событиям 'product_cart' (страница корзины), 'product_page' (страница товара), 'login'(вход). Все события 'purchase' (покупка) имеют заполненное поле details. Следовательно, данные пропуски - особенность сбора данных, и обрабатывать их не нужно.

Обработка дубликатов¶

Проверим наличие явных полных дубликатов.

In [18]:
for df in [events, marketing_events, new_users, tests]:
    display(len(df[df.duplicated()]))
0
0
0
0

Явные полные дубликаты отсутствуют.

Убедимся в отсутствии задвоенных записей о новых пользователях.

In [19]:
new_users[new_users['user_id'].duplicated()]['user_id'].count()
Out[19]:
0

Дубликатов среди идентификаторов пользователей не найдено.

Изучим дубликаты в данных о тестах.

In [20]:
tests[tests['user_id'].duplicated()]['user_id'].count()
Out[20]:
1602

1602 идентификатора пользователя встречаются в данных о тестах более одного раза. Так как полных дубликатов в данной таблице нет, следовательно указанное количество пользователей принимали участие более чем в одном тесте или ошибочно оказались в обеих группах какого-либо из тестов. Проверим эти данные позднее ири оценке корректности проведенного теста.

Выводы по предобработке¶

  • Преобразовали данные о дате/времени в соответствующий тип.
  • Добавили столбец с датой события в логи.
  • Для уменьшения занимаемого объема памяти:
    • преобразовали некоторые строковые данные в категориальный тип,
    • преобразовали тип данных стоимостей покупок во float32.
  • Убедились в отсутствии пропусков и дубликатов, требующих обработки

Оценка корректности проведения теста¶

Проверка на соответствие ТЗ¶

Соответствие количества пользователей¶

Проверим соответствует ли техническому заданию количество пользователей в списке с проводимыми тестами:

  • название теста: recommender_system_test;
  • ожидаемое количество участников теста: 6000.
In [21]:
print(
    'Количество участников по данным о тестах: {}'
    .format(tests[tests['ab_test'] == 'recommender_system_test']['user_id'].nunique())
     )
Количество участников по данным о тестах: 6701

Количество участников удовлетворяет требованиям ТЗ.

Соответствие географии пользователей¶

Проверим соответствие географии выбранных пользователей:

  • аудитория: 15% новых пользователей из региона EU;
In [22]:
eu_new_users = new_users[new_users['region'] == 'EU']['user_id']

print(
    'Количество новых пользователей из EU за период теста: {}\n'
     .format(eu_new_users.nunique()))

print(
    'Количество участников из региона EU, попавших в тест: {}\n'
    .format(tests[
        (tests['ab_test'] == 'recommender_system_test') 
        & (tests['user_id'].isin(eu_new_users))
                 ]['user_id'].nunique())
     )

print(
    'Процент пользователей из EU, попавших в тест: {}%'
    .format(
        round(tests[
            (tests['ab_test'] == 'recommender_system_test') 
            & (tests['user_id'].isin(eu_new_users))
        ]['user_id'].nunique() / eu_new_users.nunique() * 100, 2)
           )
     )
Количество новых пользователей из EU за период теста: 46270

Количество участников из региона EU, попавших в тест: 6351

Процент пользователей из EU, попавших в тест: 13.73%

В тест попали 350 пользователей из другого региона. При этом общее число пользователей из EU, попавших в тест, соответствует ТЗ, однако их процент от числа новых пользователей из региона немного не дотягивает до требований ТЗ в 15%.

Отфильтруем участников теста, удалив пользователей из несоответствующего региона.

In [23]:
participants = (
    tests[(tests['ab_test'] == 'recommender_system_test') 
        & (tests['user_id'].isin(eu_new_users))]['user_id']
               )

print('Количество участников теста после фильтрации: {}'.format(participants.count()))
Количество участников теста после фильтрации: 6351

Количество участников теста после удаления пользователей из нецелевого региона по-прежнему соответствует требованиям ТЗ.

Соответствие временному интервалу:¶

Проверим предоставленные данные на соответствие временному интервалу проведения теста:

  • дата запуска: 2020-12-07;
  • дата остановки набора новых пользователей: 2020-12-21;
  • дата остановки: 2021-01-04;
  • ожидаемый эффект - за 14 дней с момента регистрации пользователя.
In [24]:
display(events['event_dt'].min())
display(events['event_dt'].max())
Timestamp('2020-12-07 00:00:33')
Timestamp('2020-12-30 23:36:33')

Временной интервал пользовательской активности в предоставленных данных не соответствует ТЗ и ограничен датой 2020-12-30, что связано либо с неполнотой данных, либо с досрочным прекращением теста.

Пересечения участников¶

Пересечения с маркетинговыми акциями¶

Так как по условиям ТЗ A/B-тест проводился в период с 2020-12-07 по 2021-01-04 для новых пользователей из Европейского региона, зарегистрировавшихся по 2020-12-21, отфильтруем таблицу маркетинговых событий и изучим данные для выбранного региона за выбранный период.

In [25]:
marketing_events_eu = (
    marketing_events[
        (marketing_events['regions'].str.contains('EU')) 
        & (marketing_events['finish_dt'] >= '2020-12-07')
        & (marketing_events['start_dt'] <= '2020-12-30')
                    ]
                      )
marketing_events_eu
Out[25]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03

Видим, что в период проведения A/B-теста проводилась маркетинговая кампания, посвященная Новому Году и Рождеству. Провдение маркетинговой акции могло оказать влияние на результат A/B-теста. Так как у нас нет подробных данных о ее механике, руководству проекта стоит самостоятельно оценить ее возможный эффект.

Возможно, с этим связано и досрочное прекращение А/В-теста (данные в логах ограничены датой 2020-12-30).

Пересечения между группами теста¶

Проверим, имеются ли пересечения аудитории между группами исследуемого теста.

In [26]:
print(
    'Количество пользователей, попавших в обе группы теста: {}'
    .format(tests[(tests['ab_test'] == 'interface_eu_test')
        & (tests['group'] == 'A')
        & (tests['group'] == 'B')
                ]['user_id'].nunique()
           )
     )
Количество пользователей, попавших в обе группы теста: 0

Пересечений групп нет. Пользователи разделены на группы корретно.

Пересечения с другими тестами¶

Ранее мы выяснили, что в данных представлена информация о двух проведенных А/В-тестах. Проверим, имеются ли пересечения аудитории между ними.

In [27]:
other_test_participants = tests[tests['ab_test'] == 'interface_eu_test']['user_id']

print(
    'Количество пользователей, попавших в оба теста: {}'
    .format(
        tests[(tests['user_id'].isin(participants))
              & (tests['user_id'].isin(other_test_participants))]['user_id'].nunique()
           )
     )
Количество пользователей, попавших в оба теста: 1602

1602 пользователя попали в оба теста.

Проверим, как эти пользователи распределены между группами теста.

In [28]:
users_grouped = (
    tests[(tests['ab_test'] == 'recommender_system_test')
        & (tests['user_id'].isin(participants))]
    .groupby('group', as_index=False)
    .agg(users=('user_id', 'nunique'))
           )

users_grouped['users_distr'] = users_grouped['users'] / users_grouped['users'].sum()

crossed_users_grouped = (
    tests[(tests['ab_test'] == 'recommender_system_test')
        & (tests['user_id'].isin(participants))
        & (tests['user_id'].isin(other_test_participants))
                ]
    .groupby('group', as_index=False)
    .agg(cros_users=('user_id', 'nunique'))
)

crossed_users_grouped['cros_users_distr'] = (
    crossed_users_grouped['cros_users'] / crossed_users_grouped['cros_users'].sum()
                                            )

users_grouped = users_grouped.merge(crossed_users_grouped, on='group')
users_grouped['cros_users_part'] = users_grouped['cros_users'] / users_grouped['users']
users_grouped
Out[28]:
group users users_distr cros_users cros_users_distr cros_users_part
0 A 3634 0.57 921 0.57 0.25
1 B 2717 0.43 681 0.43 0.25

Участники разделены между группами теста не в равных долях, а в пропорции 57/43.

В каждой из групп четверть участников - пользователи, попавшие одновременно в два теста. Так как эти пользователи разделены между группами в той же пропорции, что и остальные участники, их участие в другом тесте окажет одинаковое влияние на обе группы исследуемого теста.

Выводы по оценке корректности теста¶

При проверке данных выявлены следующие проблемы:

  • в тестовую выборку попали 350 пользователей из нецелевого региона;
  • процент участников из целевого региона несколько ниже требвемого (13,73%);
  • временной интервал в пользовательских логах не соответствует ТЗ и ограничен данными до 2020-12-30;
  • в период проведения теста проводилась маркетинговая кампания (с 2020-12-25 по 2021-01-03), что могло оказать влияние на поведение пользователей и результат теста;
  • 1602 участника теста также попали в выборку другого теста.

Несоответствия были устранены путем подготовки отфильтрованного списка участников теста.

Остальные данные соответствуют требованиям ТЗ:

  • география пользователей;
  • общее количество участников теста (более 6000).

Также мы убедились, что пользователи корректно разделены на две группы - пересечений между группами теста нет.

Исследовательский анализ данных¶

Обзор событий в логах¶

Ранее мы установили, что в логах представлены данные о событиях четырех типов: 'login'(вход в сервис), 'product_page' (страница товара), 'product_cart' (страница корзины), 'purchase' (покупка).

Всего - 440317 записей для 58703 пользователей в период с 2020-12-07 по 2020-12-30.

Отфильтруем события, оставив только данные участников теста.

Также учтем, что для целей теста нужно использовать только первые 14 дней активности пользователей.

Добавим в логи данные о группе, к которой относится пользователь.

Оценим получившиеся данные.

In [29]:
new_users['last_date'] = new_users['first_date'] + pd.Timedelta(days=14)
In [30]:
events_filtered = (
    events[events['user_id'].isin(participants)]
    .merge(tests[['user_id', 'group']], on='user_id')
    .merge(new_users[['user_id', 'last_date']], on='user_id')
    .query('date <= last_date')
    .drop_duplicates()
    .reset_index(drop=True)
                  )
In [31]:
events_filtered.info()
print('\nDataframe keeps {} records by {} attributes\n'. format(events_filtered.shape[0], events_filtered.shape[1]))
print('Number of unique items in attributes:')
for col in events_filtered.columns:
    print('\t- number of unique "{}": {}'.format(col, events_filtered[col].nunique()))
    if events_filtered[col].nunique() < 10:
        print('\t\tthey are:')
        print('\t\t{}'.format(events_filtered[col].unique()))
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 25539 entries, 0 to 25538
Data columns (total 7 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   user_id     25539 non-null  object        
 1   event_dt    25539 non-null  datetime64[ns]
 2   event_name  25539 non-null  category      
 3   details     3481 non-null   float32       
 4   date        25539 non-null  datetime64[ns]
 5   group       25539 non-null  category      
 6   last_date   25539 non-null  datetime64[ns]
dtypes: category(2), datetime64[ns](3), float32(1), object(1)
memory usage: 948.2+ KB

Dataframe keeps 25539 records by 7 attributes

Number of unique items in attributes:
	- number of unique "user_id": 3481
	- number of unique "event_dt": 15231
	- number of unique "event_name": 4
		they are:
		['purchase', 'product_cart', 'login', 'product_page']
Categories (4, object): ['purchase', 'product_cart', 'login', 'product_page']
	- number of unique "details": 4
		they are:
		[  4.99  99.99    nan   9.99 499.99]
	- number of unique "date": 23
	- number of unique "group": 2
		they are:
		['A', 'B']
Categories (2, object): ['A', 'B']
	- number of unique "last_date": 15
In [32]:
display(events_filtered['event_dt'].min())
display(events_filtered['event_dt'].max())
Timestamp('2020-12-07 00:05:57')
Timestamp('2020-12-29 23:38:29')

Получили 25539 записей для 3481 уникального пользователя за период с 2020-12-07 по 2020-12-29.

Распределение отсутствующих пользователей¶

Количество уникальных пользователей в логах меньше их количества в списке проводимых тестов. Выделим их и изучим, как они распределены между группами теста.

In [33]:
print(
    'Количество новых пользователей, по которым отсутствуют записи в логах: {}'
    .format(participants.count() - events_filtered['user_id'].nunique())
     )

missed_users = (
    tests[
        (tests['user_id'].isin(participants)) 
        & ~(tests['user_id'].isin(events_filtered['user_id']))
         ]['user_id'].unique()
               )

missed_users_grouped = (
    tests[
        (tests['user_id'].isin(missed_users))
        & (tests['ab_test'] == 'recommender_system_test')
         ]
    .groupby('group', as_index=False)
    .agg(missed_users=('user_id','count'))
                         )
missed_users_grouped['missed_users_distr'] = (
    missed_users_grouped['missed_users'] / missed_users_grouped['missed_users'].sum()
                                              )

users_grouped = users_grouped.merge(missed_users_grouped, on='group')
users_grouped['missed_users_part'] = users_grouped['missed_users'] / users_grouped['users']
users_grouped['active_users'] = users_grouped['users'] - users_grouped['missed_users']
users_grouped['active_users_distr'] = users_grouped['active_users'] / users_grouped['active_users'].sum()
users_grouped
Количество новых пользователей, по которым отсутствуют записи в логах: 2870
Out[33]:
group users users_distr cros_users cros_users_distr cros_users_part missed_users missed_users_distr missed_users_part active_users active_users_distr
0 A 3634 0.57 921 0.57 0.25 1030 0.36 0.28 2604 0.75
1 B 2717 0.43 681 0.43 0.25 1840 0.64 0.68 877 0.25

2870 пользователей, зарегистрированных в тесте, не совершали целевых действий за период проведения теста. Эти пользователи распределены между тестовой и контрольной группой неравномерно: в тестовой группе 68% участников не совершали целевых действий.

В результате участники теста, совершавшие целевые действия, распределены между группами в соотношении 3/1: 2604 участника в контрольной группе и 877 - в тестовой.

Возможными причинами отсутствия событий могут быть как проблемы логирования, из-за которых события не записывались для некоторых пользователей, так и реальное отсутствие активности со стороны пользователей.

Ранее мы выяснили, что в период проведения теста проводилась маркетинговая кампания. Возможно, часть пользователей зарегистрировались в сервисе в результате ее проведения, но в дальнейшем отказались от использования сервиса.

Также, учитывая болшее количество неактивных пользователей именно в группе В (которой показывалась новая рекомендательная система), возможно, в сервисе имеются технические проблемы с ее работой, в результате чего у пользователей возникали проблемы с использованием сервиса.

Кроме того, ряд пользователей участвовали также в тесте нового интерфейса. Возможно, на их активность повлияли проблемы с использованием тестируемого интерфейса.

Распределение событий по пользователям¶

Оценим распределение событий по пользователям.

In [34]:
events_per_user = (
    events_filtered
    .groupby('user_id', as_index=False)
    .agg(events=('event_dt', 'count'), 
         group=('group', 'first'))

)
print('Распределение числа событий по пользователям:\n')
print('По всем участникам теста:')
display(events_per_user['events'].describe())
print('В группе А:')
display(events_per_user[events_per_user['group'] == 'A']['events'].describe())
print('В группе В:')
display(events_per_user[events_per_user['group'] == 'B']['events'].describe())
Распределение числа событий по пользователям:

По всем участникам теста:
count    3481.00
mean        7.34
std         4.75
min         1.00
25%         4.00
50%         6.00
75%         9.00
max        40.00
Name: events, dtype: float64
В группе А:
count    2604.00
mean        7.72
std         4.92
min         1.00
25%         4.00
50%         6.00
75%         9.00
max        40.00
Name: events, dtype: float64
В группе В:
count    877.00
mean       6.20
std        3.99
min        1.00
25%        3.00
50%        6.00
75%        8.00
max       24.00
Name: events, dtype: float64
In [35]:
plt.figure(figsize=(14, 4))
sns.boxenplot(data=events_per_user, x='events', y='group', order=['A', 'B'])
plt.title('Распределение числа пользовательских действий по группам теста', fontsize=(18))
plt.xlabel('Количество событий на одного пользователя', fontsize=(14))
plt.ylabel('Группа теста', fontsize=(14))
plt.show()

Медианное значение количества событий на одного пользователя равно шести в обеих группах теста. Половина всех пользователей в группе А совершает от 4 до 9 действий, в группе В - от 3 до 8.

По графикам видно, что активность пользователей в группе В смещена к меньшим значениям числа событий в сравнении с группой А. При этом наибольшее количество событий, совершенных пользователями в группе А равно 24, в группе В - 40.

То есть тестовая группа оказалась менее активной, чем контрольная.

Относительно большое количество пользователей совершили всего одно действие.

Также видим небольшое число участников теста с более чем 30 событиями. Посчитаем выборочные перцентили.

In [36]:
events_per_user['events'].quantile([0.01, 0.05, .95, .99])
Out[36]:
0.01     1.0
0.05     2.0
0.95    16.0
0.99    24.0
Name: events, dtype: float64

Видими, что не менее 99% пользователей совершают от одного до 24 действий. Отфильтруем тестовые данные, удалив пользователей с неправдоподобно большим количеством действий, чтобы их активность не влияла на результаты анализа теста.

In [37]:
unreal_users = events_per_user[events_per_user['events'] > 24]['user_id']
events_filtered = events_filtered[~(events_filtered['user_id'].isin(unreal_users))]

Распределение событий по дням¶

Оценим распределение событий по дням.

In [38]:
events_filtered['date'].hist(figsize=(12, 3), bins=(23))
plt.title('Распределение числа событий по датам')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.show()

События распределены неравномерно. Большая часть событий приходится на период с 14 по 24 декабря. При этом видим сильные всплески активности пользователей 14 декабря и 21 декабря.

Как мы помним, с 25 декабря проводилась маркетинговая кампания, однако активность пользователей в этот период не самая высокая и постепенно затухает. Можем предположить, что влияние маркетиговой акции на активность пользователей было несущественным.

Оценим распределение событий по дням в разбивке на группы теста.

In [39]:
fig = px.histogram(events_filtered,
                   x='date',
                   color='group', 
                   range_x=[events_filtered['date'].min(), events_filtered['date'].max()], 
                   title='Распределение количества событий в зависимости от группы теста', 
                   nbins=23, 
                   barmode='overlay')
fig.update_xaxes(title_text='Дата') 
fig.update_yaxes(title_text='Количество событий') 
fig.show()

Пользователи группы В совершали меньше действий. Однако, как мы помним, количество участников, попавших в группу В, меньше количества участников в группе А в три раза. Изучим распределение количества событий по дням относительно общего числа событий в каждой из групп.

In [40]:
event_per_date_grouped = (
    events_filtered.groupby(['date', 'group'], as_index=False)
    .agg(events_cnt=('event_name', 'count'))
    .sort_values(['date', 'group'])
    .reset_index(drop=True)
                      )
event_per_date_grouped['group_total_events'] = 0
for group in ['A', 'B']:
    event_per_date_grouped.loc[event_per_date_grouped['group']==group, 'group_total_events'] \
    = event_per_date_grouped.loc[event_per_date_grouped['group']==group, 'events_cnt'].sum()

event_per_date_grouped['%%'] = (
    (event_per_date_grouped['events_cnt'] / event_per_date_grouped['group_total_events'] * 100).round(2)
                               )
In [41]:
fig = px.bar(
    event_per_date_grouped, 
    x='date', y='%%', color='group',
            )
fig.update_layout(
    title='Распределение событий по датам в % от общего числа событий в каждой из групп теста',
    xaxis_title='Дата',
    yaxis_title='% событий на дату'
                 )
fig.show()

В первую неделю теста пользователи группы А совершали больше действий относительно их общего числа за весь период теста, чем пользователи группы В. Наиболее резкое повышение активности произошло 14 декабря в группе A. Затем в обех группах до 21 декабря шло плавное повышение активности. 21 декабря видим резкий всплеск активности, а 22 декабря произошел резкий спад числа событий в обеих группах (но особенно - в группе В) с последюущим постепенным снижением активности к концу теста.

Изучение воронки¶

Обзор событий в логах¶

Изучим, какие события есть в логах и как часто они встречаются, отсортируем их по частоте.

In [42]:
events_per_event = (
    events_filtered.groupby('event_name')
    .agg(events=('event_dt', 'count'))
    .sort_values('events', ascending=False)
    .reset_index()
)

events_per_event
Out[42]:
event_name events
0 login 11380
1 product_page 6968
2 purchase 3335
3 product_cart 3228

Наибольшее количество событий - вход в сервис (11380), что логично. Далее по общему числу событий идет просмотр карточки товара (6968). Что примечательно, количество покупок (3335) превышает количество просмотров корзины (3228). Видимо, в сервисе предусмотрена возможность оплвты товара без захода на страницу корзины.

Посчитаем, сколько пользователей совершали каждое из этих событий. Посчитаем долю пользователей, которые хоть раз совершили каждое из событий. Отсортируем события по числу пользователей.

In [43]:
users_per_event = (
    events_filtered.groupby('event_name')
    .agg(users=('user_id', 'nunique'))
    .sort_values('users', ascending=False)
    .reset_index()
)

users_per_event['users_%'] = users_per_event['users'] / events_filtered['user_id'].nunique() * 100

users_per_event
Out[43]:
event_name users users_%
0 login 3461 99.97
1 product_page 2160 62.39
2 purchase 1067 30.82
3 product_cart 1014 29.29

Видим, что как и с количеством событий, количество пользователей, совершавших покупки, больше количества пользователей, заходивших в корзину. Также видим, что для 0.03% пользователей отсутствует запись о входе в приложение. Возможно, имеет место ошибка логирования.

Построение воронки событий¶

Очевидно, что воронка выглядит так:

  1. 'login'(вход в сервис) ->
  2. 'product_page' (страница товара) ->
  3. 'product_cart' (страница корзины) ->
  4. 'purchase' (покупка).
In [44]:
events_per_event['event_name'] = (
    pd.Categorical(
        events_per_event['event_name'], 
        ['login', 'product_page', 'product_cart', 'purchase']
                  )
)

users_per_event['event_name'] = (
    pd.Categorical(
        users_per_event['event_name'], 
        ['login', 'product_page', 'product_cart', 'purchase']
                  )
                                )

events_per_event = events_per_event.sort_values('event_name')
users_per_event = users_per_event.sort_values('event_name')
display(events_per_event)
display(users_per_event)
event_name events
0 login 11380
1 product_page 6968
3 product_cart 3228
2 purchase 3335
event_name users users_%
0 login 3461 99.97
1 product_page 2160 62.39
3 product_cart 1014 29.29
2 purchase 1067 30.82
Воронка по количеству событий¶

Построим воронку по количеству событий на каждом этапе.

In [45]:
fig = go.Figure(
    go.Funnel(
        x=events_per_event['events'],
        y=events_per_event['event_name'],
    )
)
fig.show()
Воронка по количеству пользователей¶

Построим воронку событий сервиса по количеству пользователей на каждом этапе.

In [46]:
fig = go.Figure(
    go.Funnel(
        x=users_per_event['users'],
        y=users_per_event['event_name'],
    )
)
fig.show()

Воронка событий в разрезе групп теста¶

Изучем воронку событий в разрезе групп.

In [47]:
events_funnel_grouped = (
    events_filtered.groupby(['group', 'event_name'])
    .agg(events=('user_id', 'nunique'))
    .sort_values(['event_name', 'group'])
    .reset_index()
)

events_funnel_grouped['event_name'] = (
    pd.Categorical(
        events_funnel_grouped['event_name'], 
        ['login', 'product_page', 'product_cart', 'purchase']
                  )
)

events_funnel_grouped = events_funnel_grouped.sort_values(['event_name', 'group'])

events_funnel_grouped['conversion'] = 0
for group in ['A', 'B']:
    events_funnel_grouped.loc[events_funnel_grouped['group'] == group, 'conversion'] = (
            events_funnel_grouped.loc[events_funnel_grouped['group'] == group, 'events'] / 
            events_filtered.loc[events_filtered['group'] == group, 'user_id'].nunique()
        ) * 100

events_funnel_grouped
Out[47]:
group event_name events conversion
0 A login 2701 100.00
1 B login 1182 99.92
4 A product_page 1729 64.01
5 B product_page 684 57.82
2 A product_cart 800 29.62
3 B product_cart 328 27.73
6 A purchase 846 31.32
7 B purchase 333 28.15
In [48]:
fig = px.funnel(events_funnel_grouped, x='events', y='event_name', color='group')
fig.show()
In [49]:
funnel_grouped = ( 
    events_funnel_grouped.loc[events_funnel_grouped['group'] == 'A', ['event_name', 'events', 'conversion']]
    .merge(
        events_funnel_grouped.loc[events_funnel_grouped['group'] == 'B', ['event_name', 'events', 'conversion']],
        on='event_name', suffixes=('_a', '_b')
          )
)

funnel_grouped['conversion_step_a'] = (
    funnel_grouped['events_a'].div(funnel_grouped['events_a'].shift(1)).fillna(1) * 100
)
funnel_grouped['conversion_step_b'] = (
    funnel_grouped['events_b'].div(funnel_grouped['events_b'].shift(1)).fillna(1) * 100
)

funnel_grouped
Out[49]:
event_name events_a conversion_a events_b conversion_b conversion_step_a conversion_step_b
0 login 2701 100.00 1182 99.92 100.00 100.00
1 product_page 1729 64.01 684 57.82 64.01 57.87
2 product_cart 800 29.62 328 27.73 46.27 47.95
3 purchase 846 31.32 333 28.15 105.75 101.52
  • Наибольший процент пользователей теряется на этапе перехода со страницы товара на страницу корзины (более 50% от количества на предыдущем этапе). При этом количество покупателей превышает количество пользователей, просматривающих страницу корзины. Очевидно, что покупку можно совершить, минуя этот этап. Возможно, его полное исключение также может повывсить итоговую конверсию в сервисе.

  • От входа в приложение до оплаты доходят около 30% пользователей. Общая конверсия в покупку в группе В ниже, чем в группе А (28,15% против 31,32%).

  • Конверсия в группе В ниже почти на всех этапах воронки, кроме этапа перехода со страницы товара на страницу корзины - здесь доля пользователей, переходящих из одного этапа воронки в другой чуть выше у групы В (47,95% против 46,27%).

Возможно, новая рекомендательная система мотивирует пользователей чаще добавлять товар в корзину, в том числе, не заходя на страницу самого товара. Однако, она не доводит пользователей до покупки.

Прежде чем приступать к тестированию статистических гипотез, мы должны учесть, что покупка товара возможна без захода на страницу корзины. А так же то, что пользователи распределены между группами теста очень неравномерно.

Оценка результатов А/В-теста¶

Ожидаемый эффект от внедрения новой рекомендательно системы - улучшение конверсии на каждом этапе не менее, чем на 10%:

  • просмотр карточек товаров — product_page,
  • просмотры корзины — product_cart,
  • покупки — purchase.

Мы выяснили, что по большнству метрик группа В показала ухудшение результата.

Перейдем к проверке статистической значимости различий между группами теста. В рассматриваемом тесте будем проверять гипотезу о равенстве пропорций. Для этого применим z-тест.

Всего будем иметь три попарных сравнения для каждого из этапов воронки (просмотр карточки товара, просмотр корзины, покупка). Следовательно, для всех тестов введем поправку для критерия значимости с коэффициентом равным 3. Применим для расчетов метод Шидака, сохраняющий групповую вероятность ошибки первого рода меньше альфа, но повышающий мощность теста.

Обозначим нулевую и альтернативную гипотезы для каждого из попарных сравнений:

Н0: доли посетителей на каждом из этапов воронки между группами теста одинаковы.
Н1: доли посетителей на каждом из этапов воронки между группами теста различаются.

Уровень значимости примем равным 5%.

In [50]:
alpha = 0.05
ratio = 3

Для удобства оформим расчеты в виде функции.

In [51]:
def z_test(sample1, sample2, event, alpha, ratio):

    # скорректированный уровень значимости
    alpha_corr = 1 - (1 - alpha) ** (1 / ratio)
    
    # размеры сравниваемых групп
    trials = np.array([sample1['user_id'].nunique(), sample2['user_id'].nunique()])
    # число пользователей, дошедших до события event в каждой из групп
    successes = np.array([sample1[sample1['event_name'] == event]['user_id'].nunique(),
                          sample2[sample2['event_name'] == event]['user_id'].nunique()])
    
    # пропорции успехов в группах
    p1 = successes[0] / trials[0]
    p2 = successes[1] / trials[1]
    # пропорции успехов в комбинированном датасэте
    p_comb = (successes[0] + successes[1]) / (trials[0] + trials[1])
    # разница пропорций в датасэтах
    difference = p1 - p2
    
    # считаем z-статистику
    z_value = difference / mth.sqrt(p_comb * (1 - p_comb) * (1 / trials[0] + 1 / trials[1]))
    
    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)
    
    p_value = (1 - distr.cdf(abs(z_value))) * 2 # двусторонний тест
    print('Событие:', event)
    print('p-значение: ', p_value)

    if p_value < alpha_corr:
        print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
    else:
        print(
            'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными',
        )
    print()

Проверим, есть ли между группами теста статистически значимая разница в конверсии на обозначенных этапах.

In [52]:
for event in ['product_page', 'product_cart', 'purchase']:
    
    z_test(
        events_filtered[events_filtered['group'] == 'A'], 
        events_filtered[events_filtered['group'] == 'B'], 
        event=event, alpha=alpha, ratio=ratio
    )
Событие: product_page
p-значение:  0.00024961296583159154
Отвергаем нулевую гипотезу: между долями есть значимая разница

Событие: product_cart
p-значение:  0.23178904667542177
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: purchase
p-значение:  0.047778911591688455
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Результаты теста.

  1. Выявлено статистически значимое различие между конверсиями в карточку товара. Низкое р-значение указывает на то, что снижение конверсии в группе В не случайно, а является результатом применения тестируемой рекомендательной системы. Из-за тестируемой рекомендательной системы пользователи резе просматривают карточки товаров.

  2. Отсутствует статистически значимое различие между конверсиями в переход к корзине и в оплате покупки. Следовательно, применение тестируемой рекомендательной системы не приводит к изменению в конверсии на этих этапах.

Общие выводы¶

  • В тестовую выборку попали 350 пользователей из нецелевого региона. Следует изучить причину.

  • Временной интервал в пользовательских логах не соответствует ТЗ и ограничен данными до 2020-12-30. Однако, последующий анализ активности показал, что количество событий начало снижаться 22 декабря и к 29 декабря достигло минимальных значений.

  • В период проведения теста проводилась маркетинговая кампания (с 2020-12-25 по 2021-01-03). Однако, анализ активности пользователей в рассматриваемый период не указывает на наличие сильного влияния маркетинговой кампании.

  • 1602 участника теста также попали в выборку другого теста. Однако, эти пользователи распределены между группами изучаемого теста равномерно, а, следовательно, одинаково влияют на обе группы теста.

  • Разделение на группы проведено корректно: пересечений между группами нет, а пользователи разделены в соотношении 57/43.

  • 2870 пользователей, отобранных для эксперимента, не совершали целевых действий за период проведения теста. Они распределились неравномерно: 64% неактивных участников оказались в группе В. В результате участники теста, совершавшие целевые действия, распределились между группами в соотношении 3/1: 2604 участника в контрольной группе и 877 - в тестовой.

  • Медианное количество событий на одного пользователя равно шести в обеих группах теста. Половина всех пользователей в группе А совершает от 4 до 9 действий, в группе В - от 3 до 8. Активность пользователей в группе В смещена к меньшим значениям в сравнении с группой А. То есть тестовая группа оказалась менее активной, чем контрольная.

  • В первую неделю теста пользователи группы А совершали больше действий относительно их общего числа за весь период теста, чем пользователи группы В. 14 декабря произошло резкое повышение активности, особенно - в группе A. Затем в обех группах до 21 декабря шло плавное повышение активности. 21 декабря случился резкий всплеск активности, а 22 декабря произошел резкий спад числа событий в обеих группах (но особенно - в группе В) с последюущим постепенным снижением активности к концу теста.

  • Наибольший процент пользователей теряется на этапе перехода со страницы товара на страницу корзины (более 50% от количества на предыдущем этапе). При этом количество покупателей превышает количество пользователей, просматривающих страницу корзины. Очевидно, что покупку можно совершить, минуя этот этап. Возможно, его полное исключение также может повывсить итоговую конверсию в сервисе.

  • От входа в сервис до оплаты доходят около 30% пользователей. Общая конверсия в покупку в группе В ниже, чем в группе А (28,15% против 31,32%).

  • Конверсия в группе В ниже почти на всех этапах воронки, кроме этапа перехода со страницы товара на страницу корзины - здесь доля пользователей, переходящих из одного этапа воронки в другой чуть выше у групы В (47,95% против 46,27%).

  • Выявлено статистически значимое различие между конверсиями в карточку товара. Низкое р-значение указывает на то, что снижение конверсии в группе В не случайно, а является результатом применения тестируемой рекомендательной системы. Из-за тестируемой рекомендательной системы пользователи реже просматривают карточки товаров.

  • Отсутствует статистически значимое различие между конверсиями в переход к корзине и в оплате покупки. Следовательно, применение тестируемой рекомендательной системы не приводит к изменению в конверсии на этих этапах.

Результаты теста указывают на ухудшение показателей при применении новой рекомендательной системы.